Рассматривается стартап - онлайн магазин по продаже продуктов питания. По имеющимуся логу событий мобильного приложения необходимо проанализировать действия пользователей при покупке товаров.
Основные задачи:
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import scipy.stats as st
import numpy as np
def mf_diff(ind):
""" подсчет количества пользователей до и после отсечения данных в разрезе заданного атрибута """
id_est = (
df_data.pivot_table(index=ind, aggfunc={'id':'nunique'})
.join(df_data2.pivot_table(index=ind, aggfunc={'id':'nunique'}), lsuffix='_before',rsuffix='_after')
)
id_est = id_est.append(id_est.apply('sum', axis=0).reset_index().set_index('index').rename({0:'Total'}, axis=1).T)
id_est['diff']=id_est.id_before-id_est.id_after
id_est['lost_share_proc'] = id_est.apply(lambda x: round(x['diff']*100/x['id_before'], 1) , axis=1)
id_est.index.name=ind
return id_est
def mf_bar(df, x_, y_, title, x_text, y_text):
""" построение графика - гистограммы """
fig=px.bar(df, x=x_, y=y_, text_auto=True)
fig.update_layout(height=500, width=800, title_text=title)
fig.update_xaxes(title_text=x_text)
fig.update_yaxes(title_text=y_text)
fig.show()
def mf_z_test(goal_list, all_list, alpha=0.05):
""" Расчет p-value для биномиального распределения (z-тест)"""
goal = np.array(goal_list) # числитель конверсии
alll = np.array(all_list) # знаменатель конверсии
p1 = goal[0]/alll[0] # пропорция успехов в 1 группе
p2 = goal[1]/alll[1] # пропорция успехов во 2 группе
p_combined = (goal[0]+ goal[1])/(alll[0] + alll[1]) # пропорция успехов в комбинированной группе:
difference = p1 - p2 # разница пропорций в группах
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / ((p_combined * (1 - p_combined) * (1 / alll[0] + 1 / alll[1]))**0.5)
distr = st.norm(0, 1) # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
if p_value < alpha:
result = 'H1'
else:
result = 'H0'
return [p_value, alpha, result]
df_data = pd.read_csv('data.csv', sep="\t")
df_data.columns = [x.lower() for x in df_data.columns]
df_data.info()
print(f"\nОбъем занимаемой памяти датасета: {(df_data.memory_usage('deep')/(1024**2)).sum():.1f} Mb\n")
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 eventname 244126 non-null object 1 deviceidhash 244126 non-null int64 2 eventtimestamp 244126 non-null int64 3 expid 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB Объем занимаемой памяти датасета: 7.5 Mb
# переименование столбцов к удобному виду
df_data.rename({'eventname':'event', 'deviceidhash':'id', 'eventtimestamp':'ts', 'expid':'gr'}, axis=1, inplace=True)
df_data.head()
| event | id | ts | gr | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
# анализ значений столбца групп
df_data.gr.value_counts()
248 85747 246 80304 247 78075 Name: gr, dtype: int64
# проверка на полные дубликаты строк
print(f"Количество строк - полных дублей: {df_data.duplicated().sum()}")
Количество строк - полных дублей: 413
df_data.drop_duplicates(inplace=True)
print(f"Количество строк - полных дублей: {df_data.duplicated().sum()}")
Количество строк - полных дублей: 0
# дата события преобразуется к типу дата-время
df_data.ts = pd.to_datetime(df_data.ts, unit='s')
# индекс группы преобазуется к целочисленному
df_data.gr = pd.to_numeric(df_data.gr, downcast='unsigned')
# итоговая структура датафрейма после преобразования
print(f"\nОбъем занимаемой памяти датасета после преобразования: {(df_data.memory_usage('deep')/(1024**2)).sum():.1f} Mb\n")
df_data.info()
Объем занимаемой памяти датасета после преобразования: 7.7 Mb <class 'pandas.core.frame.DataFrame'> Int64Index: 243713 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event 243713 non-null object 1 id 243713 non-null int64 2 ts 243713 non-null datetime64[ns] 3 gr 243713 non-null uint8 dtypes: datetime64[ns](1), int64(1), object(1), uint8(1) memory usage: 7.7+ MB
# создается отдельный столбец дат
df_data['dt'] = df_data['ts'].dt.date
# всего событий в данных
df_data.event.value_counts().reset_index().rename({'index':'Событие','event':'Количество событий'}, axis=1)
| Событие | Количество событий | |
|---|---|---|
| 0 | MainScreenAppear | 119101 |
| 1 | OffersScreenAppear | 46808 |
| 2 | CartScreenAppear | 42668 |
| 3 | PaymentScreenSuccessful | 34118 |
| 4 | Tutorial | 1018 |
# количество уникальных пользователей
print(f"Количество уникальных пользователей: {len(df_data.id.unique())}")
Количество уникальных пользователей: 7551
# среднее количество событий на пользователя
print("Медиана количества " +
f"событий на пользователя: {df_data.pivot_table(index='id', aggfunc={'event':'count'}).median()[0]:.0f}")
Медиана количества событий на пользователя: 20
Промежуточные выводы
# диапазон дат в данных
print(f"Данные представлены с {df_data.dt.min()} по {df_data.dt.max()}")
Данные представлены с 2019-07-25 по 2019-08-07
# подготовка данных
temp = (
df_data
.pivot_table(index=df_data.ts.dt.round('1h'), aggfunc={'event':'count'})
.reset_index()
)
# подготовка графика
fig = px.line(temp, x="ts", y="event", title='Динамика зафиксированных событий')
fig.update_xaxes(title_text="Дата")
fig.update_yaxes(title_text="Количество событий")
fig.show()
Промежуточные выводы
# отсекаются дни с неполными данными
df_data2 = df_data.query("ts >= '2019-08-01'")
print(f"Количество уникальных пользователей после отсечения данных: {df_data2.id.nunique()}")
Количество уникальных пользователей после отсечения данных: 7534
# оценка потери данных по пользователям в разрезе событий
mf_diff('event')
| id_before | id_after | diff | lost_share_proc | |
|---|---|---|---|---|
| event | ||||
| CartScreenAppear | 3749 | 3734 | 15 | 0.4 |
| MainScreenAppear | 7439 | 7419 | 20 | 0.3 |
| OffersScreenAppear | 4613 | 4593 | 20 | 0.4 |
| PaymentScreenSuccessful | 3547 | 3539 | 8 | 0.2 |
| Tutorial | 847 | 840 | 7 | 0.8 |
| Total | 20195 | 20125 | 70 | 0.3 |
# оценка потери данных по пользователям в разрезе групп
mf_diff('gr')
| id_before | id_after | diff | lost_share_proc | |
|---|---|---|---|---|
| gr | ||||
| 246 | 2489 | 2484 | 5 | 0.2 |
| 247 | 2520 | 2513 | 7 | 0.3 |
| 248 | 2542 | 2537 | 5 | 0.2 |
| Total | 7551 | 7534 | 17 | 0.2 |
Промежуточные выводы
# подготовка данных
grd1 = (
df_data2
.pivot_table(index='event', aggfunc={'event':'count'})
.rename({'event':'count'}, axis=1)
.reset_index()
.sort_values(by='count', ascending=False)
)
# подготовка графика
fig=px.pie(grd1, names='event', values='count')
fig.update_layout(height=500, width=500, title_text="Доли событий в общем объеме всех событий")
fig.show()
mf_bar(grd1, 'event', 'count', 'Гистограмма количества событий','Cобытия', 'Количество событий')
Промежуточные выводы
# подготовка данных без этапа Tutorial
df_data3 = df_data.query("ts >= '2019-08-01' and event != 'Tutorial'")
# подсчет количества уникальных пользователей на каждом шаге воронки
grd2 = (
df_data3.pivot_table(index='event', aggfunc={'id':'nunique'})
.reset_index()
.rename({'id':'count'}, axis=1)
.sort_values(by='count', ascending=False)
.reset_index(drop=True)
)
# подсчет конверсии к предыдущему событию
grd2['shift'] = (
grd2['count']
.shift(periods=1, axis=0)
)
grd2['conversion'] = grd2.apply(lambda x: round(x['count']*100/x['shift'],1), axis=1)
grd2['delta'] = grd2['shift'] - grd2['count']
grd2
| event | count | shift | conversion | delta | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | NaN | NaN | NaN |
| 1 | OffersScreenAppear | 4593 | 7419.0 | 61.9 | 2826.0 |
| 2 | CartScreenAppear | 3734 | 4593.0 | 81.3 | 859.0 |
| 3 | PaymentScreenSuccessful | 3539 | 3734.0 | 94.8 | 195.0 |
# построение воронки продажи продуктов питания
fig = go.Figure(
go.Funnel(y=list(grd2.reset_index()['event']), x=list(grd2.reset_index()['count'])
))
fig.update_layout(height=500, width=950, title = "Воронка продажи продуктов питания на сайте")
fig.update_yaxes(title_text="Шаги воронки")
fig.show()
Промежуточные выводы
# проверка на переток пользователей из группы в группу
print("Количество пользователей, перешедших в ходе теста в другие группы: "+
f"{df_data3.pivot_table(index='id', aggfunc={'gr':'nunique'}).query('gr>1').count()[0]}")
#df_data3.pivot_table(index='gr', aggfunc={'id':'nunique'})
Количество пользователей, перешедших в ходе теста в другие группы: 0
# количество пользователей, участвующих в А/В-тесте
grd=df_data3.pivot_table(index='gr', aggfunc={'id':'nunique'}).reset_index()
mf_bar(grd, 'gr', 'id', 'Количество уникальных групп по группам А/В-теста', 'Номер группы теста', 'Количество пользователей')
print("Проверка равенства числа пользователей по группам теста:")
print(f"246/247: {grd.set_index('gr').loc[246, 'id']/grd.set_index('gr').loc[247, 'id']:.3f}")
print(f"247/248: {grd.set_index('gr').loc[247, 'id']/grd.set_index('gr').loc[248, 'id']:.3f}")
print(f"246/248: {grd.set_index('gr').loc[246, 'id']/grd.set_index('gr').loc[248, 'id']:.3f}")
Проверка равенства числа пользователей по группам теста: 246/247: 0.988 247/248: 0.991 246/248: 0.979
Промежуточные выводы
# воронка продаж по трем группам
grd= (
df_data3
.pivot_table(index='event', columns='gr', aggfunc={'id':'nunique'})
.droplevel(level=0, axis=1)
.sort_values(by=246, ascending=False)
)
grd
| gr | 246 | 247 | 248 |
|---|---|---|---|
| event | |||
| MainScreenAppear | 2450 | 2476 | 2493 |
| OffersScreenAppear | 1542 | 1520 | 1531 |
| CartScreenAppear | 1266 | 1238 | 1230 |
| PaymentScreenSuccessful | 1200 | 1158 | 1181 |
# построение сегментированной воронки
fig = go.Figure()
for i in grd.columns: # по всем группам
fig.add_trace(go.Funnel(
name = i,
y = list(grd.index),
x = list(grd.loc[:,i])
))
fig.update_layout(title = "Воронка продажи продуктов питания на сайте по группам А/В теста")
fig.update_yaxes(title_text="Шаги воронки")
fig.show()
План анализа результатов А/В теста
# подготовка данных - количество уникальных пользователей по каждой группе по шагам воронки
df_t= df_data3.pivot_table(index='gr', columns='event', aggfunc={'id':'nunique'}).droplevel(level=0, axis=1)
# добавление совмещенных данных по 246 и 247 группам и сортировка по шагам воронки
df_t = (
df_t.append(
df_data3.query("gr in (246,247)")
.pivot_table(index='event', aggfunc={'id':'nunique'})
.rename({'id':'246_247'}, axis=1)
.T
)
.T
.sort_values(by=246, ascending=False)
.T
)
# добавление итоговых значений по группам
df_t['total']=(
df_data3
.pivot_table(index='gr', aggfunc={'id':'nunique'})
.append(pd.DataFrame(df_data3.query("gr in (246,247)")['id'].nunique(), columns=['id'], index=['246_247']))
.rename({'id':'total'}, axis=1)
)
print("Количество уникальных пользователей по группам и шагам воронки:")
df_t
Количество уникальных пользователей по группам и шагам воронки:
| event | MainScreenAppear | OffersScreenAppear | CartScreenAppear | PaymentScreenSuccessful | total |
|---|---|---|---|---|---|
| 246 | 2450 | 1542 | 1266 | 1200 | 2483 |
| 247 | 2476 | 1520 | 1238 | 1158 | 2512 |
| 248 | 2493 | 1531 | 1230 | 1181 | 2535 |
| 246_247 | 4926 | 3062 | 2504 | 2358 | 4995 |
# проверка долей пользователей по шагам воронки по группам
print("Проверка долей пользователей по шагам воронки по группам в процентах:")
df_t.apply(lambda x: x*100/x['total'], axis=1)
Проверка долей пользователей по шагам воронки по группам в процентах:
| event | MainScreenAppear | OffersScreenAppear | CartScreenAppear | PaymentScreenSuccessful | total |
|---|---|---|---|---|---|
| 246 | 98.670963 | 62.102296 | 50.986710 | 48.328635 | 100.0 |
| 247 | 98.566879 | 60.509554 | 49.283439 | 46.098726 | 100.0 |
| 248 | 98.343195 | 60.394477 | 48.520710 | 46.587771 | 100.0 |
| 246_247 | 98.618619 | 61.301301 | 50.130130 | 47.207207 | 100.0 |
# пороговый уровень стат значимости
alpha = 0.05
# поправка Шидака для 4 шагов воронки и 4 сравнений
alpha_sh = 1-(1-alpha)**(1/(4*4))
print(f"Пороговый уровень значимости после поправки Шидака: {alpha_sh:.2%}")
Пороговый уровень значимости после поправки Шидака: 0.32%
# расчет всех стат тестов
voc1={'246 с 247 (А1/А2)':[246, 247], '246 с 248 (А1/В)':[246, 248]
, '247 c 248 (А2/В)':[247, 248], '246 и 247 с 248 (А12/В)':['246_247', 248]}
for j in voc1:
groups = voc1[j]
print(f"\n\n---- Сравнение групп {j} по шагам воронки:")
for i in df_t.columns:
if i !='total':
res = mf_z_test([df_t.loc[groups[0], i], df_t.loc[groups[1], i]]
, [df_t.loc[groups[0], 'total'], df_t.loc[groups[1], 'total']], alpha_sh)
if res[2] == 'H0':
text = f"нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости {res[0]:.1%}"
else:
text = f"принята альтернативная гипотеза (доли разные) на уровне значимости {res[0]:.1%}"
print(f"{i}: \n "+text)
---- Сравнение групп 246 с 247 (А1/А2) по шагам воронки:
MainScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 75.3%
OffersScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 24.8%
CartScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 22.9%
PaymentScreenSuccessful:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 11.4%
---- Сравнение групп 246 с 248 (А1/В) по шагам воронки:
MainScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 33.9%
OffersScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 21.4%
CartScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 8.1%
PaymentScreenSuccessful:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 21.7%
---- Сравнение групп 247 c 248 (А2/В) по шагам воронки:
MainScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 51.9%
OffersScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 93.3%
CartScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 58.8%
PaymentScreenSuccessful:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 72.8%
---- Сравнение групп 246 и 247 с 248 (А12/В) по шагам воронки:
MainScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 34.9%
OffersScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 44.6%
CartScreenAppear:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 18.7%
PaymentScreenSuccessful:
нулевая гипотеза не отвергнута (доли одинаковы) на уровне значимости 61.1%
Промежуточные выводы
Предобработка и добавление расчетов
Анализ данных
Анализ результатов А/В-теста